起因:Wayland 上沒有 set_visible#
在開發 Rustle(自己的音樂軟體)時,我需要實現 "最小化到系統托盤" 功能:
- 點擊關閉按鈕時,窗口隱藏而不是退出程序
- 點擊托盤圖標時,窗口重新顯示
- 程序在後台繼續運行(daemon 模式)
雖然使用 daemon 模式可以做到後台運行,但是 Iced 和 winit 的默認策略是關掉整個窗口,再需要時再喚出,但是由於我打算使用 GPU 渲染而不是軟渲染,所以創建一個新的 GPU 上下文再重新初始化 vulkan 生命週期再調用 wgpu 繪製軟體界面,這個冷啟動的過程足足有 500ms!所以必須保存 vulkan/opengl 生命週期,則不能銷毀窗口,而是令其不可見。
在 X11 上,這很簡單 —— 調用 window.set_visible(false/true) 即可。但在 Wayland 上:
// winit 的 Wayland 實現
pub fn set_visible(&self, _visible: bool) {
// Not possible on Wayland.
}
winit 直接放棄了這個功能,註釋寫著 "Wayland 上不可能實現"。(?怎麼可能)
查閱 Wayland 協議文檔後發現,Wayland 的設計哲學與 X11 截然不同:
- 沒有全局窗口管理器 API:客戶端不能直接操作窗口的顯示狀態
- Compositor 主導一切:窗口的顯示、隱藏、位置都由 compositor 決定
- 只有
set_minimized:但這個操作是單向的—— 程序無法通過代碼恢復最小化的窗口
翻遍全網也沒找到一樣的問題。但真的沒有辦法嗎?
探索:GTK、Chromium 是怎麼做的?#
GTK 的實現#
查看 GTK 源碼發現了關鍵線索:
// gdk/wayland/gdkwindow-wayland.c
static void gdk_wayland_window_hide(GdkWindow *window) {
GdkWindowImplWayland *impl = GDK_WINDOW_IMPL_WAYLAND(window->impl);
wl_surface_attach(impl->display_server.wl_surface, NULL, 0, 0);
wl_surface_commit(impl->display_server.wl_surface);
_gdk_window_clear_update_area(window);
}
關鍵發現:GTK 通過 wl_surface_attach(NULL) 來隱藏窗口!
XDG Shell 協議規範#
查閱 XDG Shell 協議文檔,找到了官方說明:
Attaching a null buffer to a toplevel unmaps the surface.
The client can re-map the toplevel by performing a commit without any buffer attached, waiting for a configure event and handling it as usual.
這意味著:
- 隱藏:
attach(NULL)+commit()→ surface 被 unmap - 顯示:
commit()→ 觸發 configure event → 重新渲染
Chromium 的實現#
進一步研究 Chromium 的 Wayland 實現:
// WaylandToplevelWindow::Hide()
void WaylandToplevelWindow::Hide() {
shell_toplevel_.reset(); // 銷毀 xdg_toplevel
connection()->buffer_manager_host()->ResetSurfaceContents(root_surface());
}
// WaylandToplevelWindow::Show()
void WaylandToplevelWindow::Show(bool inactive) {
if (!CreateShellToplevel()) { ... } // 重新創建 xdg_toplevel
}
Chromium 採用了更激進的方案 —— 銷毀並重建 xdg_toplevel。但我後來發現這種方式在 Hyprland 上會導致 compositor 崩潰(這對嗎?)。
實現:修改 winit#
架構概覽#
┌────────────────────────────┐
│ Rustle (應用層) │
├────────────────────────────┤
│ iced (GUI 框架) │
├────────────────────────────┤
│ iced_winit (窗口管理) │
├────────────────────────────┤
│ winit (窗口抽象) │
├────────────────────────────┤
│ smithay-client-toolkit (Wayland 封裝) │
├────────────────────────────┤
│ wayland-client (協議綁定) │
├────────────────────────────┤
│ Wayland Compositor │
└────────────────────────────┘
需要修改的層:
- iced: 添加
set_visibleAPI - winit: 實現 Wayland 上的
set_visible
winit 的修改#
核心實現(src/platform_impl/linux/wayland/window/mod.rs):
pub fn set_visible(&self, visible: bool) {
// 根據 XDG Shell 協議:
// - "Attaching a null buffer to a toplevel unmaps the surface."
// - "The client can re-map the toplevel by performing a commit without any
// buffer attached, waiting for a configure event and handling it as usual."
let surface = self.window.wl_surface();
if visible {
{
let mut state = self.window_state.lock().unwrap();
state.set_visible(true);
// 重置 frame callback 狀態,打破死鎖
state.frame_callback_reset();
}
surface.commit();
self.request_redraw();
} else {
self.window_state.lock().unwrap().set_visible(false);
// 清空待處理的 redraw 請求
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);
// 按協議 unmap:attach(NULL) + commit
surface.attach(None, 0, 0);
surface.commit();
}
}
iced 的修改#
添加 Action(runtime/src/window.rs):
pub enum Action {
// ...existing actions...
/// 設置窗口的可見性。
SetVisible(Id, bool),
}
/// 設置窗口的可見性。
pub fn set_visible<T>(id: Id, visible: bool) -> Task<T> {
task::effect(crate::Action::Window(Action::SetVisible(id, visible)))
}
處理 Action(winit/src/lib.rs):
window::Action::SetVisible(id, visible) => {
if let Some(window) = window_manager.get_mut(id) {
window.raw.set_visible(visible);
}
}
不是哥們:那些令人頭疼的 Bug#
Hyprland Compositor 崩潰(為什麼不能銷毀 xdg_toplevel?)#
最初的方案:參考 Chromium 的實現,銷毀 xdg_toplevel 來隱藏窗口,重建它來顯示窗口。
問題:在 Hyprland 上,銷毀並重建 xdg_toplevel 會導致 compositor 崩潰,回到 SDDM 界面
// Hyprland 崩潰堆棧
CWindow::create(CXDGSurfaceResource)
CWLSurface::assign
CWLSurface::init // 崩潰點
根本原因:Hyprland 不能正確處理在同一個 xdg_surface 上重新創建 xdg_toplevel 的情況。
最終方案:完全避免銷毀 xdg_toplevel,只使用 XDG Shell 協議規定的 wl_surface.attach(NULL) 方法:
- 隱藏:
attach(NULL)+commit()→ surface 被 unmap - 顯示:
commit()+request_redraw()→ 重新渲染
這個方案:
- 完全符合 XDG Shell 協議
- 不破壞
xdg_toplevel生命週期 - 兼容所有 compositor(包括 Hyprland)
- 代碼更簡潔,不需要複雜的生命週期管理
隱藏後無法恢復顯示#
現象:set_visible(false) 成功隱藏窗口,但 set_visible(true) 後窗口不出現。
原因:Frame callback 死鎖。
┌───────────────────────────┐
│ wgpu 等待 frame callback 才能提交 buffer │
│ ↓ │
│ compositor 等待 buffer 才能發送 frame callback │
│ ↓ │
│ 死鎖! │
└───────────────────────────┘
當窗口隱藏(attach(NULL))後,compositor 不再發送 frame callback。但 winit 的渲染循環依賴 frame callback 來知道何時渲染下一幀。
解決方案:在 set_visible(true) 時重置 frame callback 狀態:
state.frame_callback_reset(); // 重置為 None,允許立即重繪
隱藏時窗口閃爍#
現象:set_visible(false) 時窗口消失後又閃現一次,而且每次閃現的次數會累積。
原因:Client-Side Decorations (CSD) 的刷新邏輯。
winit 的事件循環會周期性調用 refresh_frame() 來更新窗口裝飾。即使窗口已經隱藏,如果 CSD 框架認為自己是 "dirty" 的,它仍然會觸發重繪 —— 這會重新 attach buffer,導致窗口又出現。
解決方案:多層防護:
// 1. refresh_frame() 中檢查 visible
pub fn refresh_frame(&mut self) -> bool {
if !self.visible {
return false; // 隱藏時不刷新裝飾
}
// ...
}
// 2. request_redraw() 中檢查 visible
pub fn request_redraw(&self) {
if !self.window_state.lock().unwrap().visible() {
return; // 隱藏時不請求重繪
}
// ...
}
// 3. set_visible(false) 時清空 pending redraw
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);
// 4. event loop 派發時檢查 visible
if !window.visible() {
window_requests.get(window_id).unwrap().take_redraw_requested();
return None; // 不派發 RedrawRequested
}
最終方案總結#
核心原理#
隱藏窗口:
┌────────────────────────┐
│ 1. set_visible(false) │
│ 2. 設置 visible 狀態為 false │
│ 3. 清空 pending redraw 請求 │
│ 4. wl_surface.attach(NULL, 0, 0) │
│ 5. wl_surface.commit() │
│ → Surface 被 unmap,compositor 不再顯示它 │
└────────────────────────┘
顯示窗口:
┌────────────────────────┐
│ 1. set_visible(true) │
│ 2. 設置 visible 狀態為 true │
│ 3. 重置 frame_callback_state (打破死鎖) │
│ 4. wl_surface.commit() │
│ 5. request_redraw() │
│ → 觸發重繪,wgpu attach buffer,窗口重新出現 │
└────────────────────────┘
修改的文件#
| 項目 | 文件 | 修改內容 |
|---|---|---|
| winit | src/.../wayland/window/mod.rs | 實現 set_visible();在 request_redraw() 中檢查 visible |
| winit | src/.../wayland/window/state.rs | 添加 visible 字段;在 refresh_frame() 中檢查 visible |
| winit | src/.../wayland/event_loop/mod.rs | 在 RedrawRequested 派發前檢查 visible 並清空 pending redraw |
| iced | runtime/src/window.rs | 添加 SetVisible action 和 set_visible() 函數 |
| iced | winit/src/lib.rs | 處理 SetVisible action,調用 window.raw.set_visible() |
使用方式#
// 在 iced 應用中
Message::ToggleWindow => {
self.window_hidden = !self.window_hidden;
let visible = !self.window_hidden;
return iced::window::latest().and_then(move |id| {
iced::window::set_visible(id, visible)
});
}
參考資料#
協議文檔#
源碼參考#
相關 Issue#
作者注:本實現基於 winit 0.30.12、iced 0.14.0。不同版本可能需要調整。
代碼倉庫:
- Rustle: https://github.com/ArcticFoxNetwork/Rustle
- winit fork: https://github.com/ArcticFoxNetwork/winit (wayland-visibility 分支)
- iced fork: https://github.com/ArcticFoxNetwork/iced (wayland-visibility 分支)